Skip to content

Conversation

@staskus
Copy link
Contributor

@staskus staskus commented Oct 23, 2025

WOOMOB-1101

Description

  1. To show a loading screen for the missing catalog, I implemented a mechanism to track the progress of ongoing sync within POSCatalogSyncCoordinator:
  • fullSyncStateStream to observe ongoing sync state: notStarted, started, completed, error...
  • lastFullSyncState to check a cached last state if it was completed before
  1. Created POSCatalogLoadingView to be displayed when the catalog loads. Added matched geometry between a loading view and a catalog view, since it's impossible not to show a normal loading view, at least for a split second. And the progress view position doesn't fall between the two views since the progress view is centered together with the text in the catalog view.

  2. Expanded PointOfSaleDashboardViewHelper and PointOfSaleDashboardView.ViewState to show catalogSync state

  3. Removed an unnecessary initial delay for syncing the inventory.

Error handling will be done separately: WOOMOB-1565

It also made me think of creating a task for caching the eligibility check, and if the POS becomes ineligible, just showing a full-screen overlay. It's an unlikely scenario, and it's better to show POS immediately to give a fast execution impression.

Steps to reproduce

Fresh login

  1. Fresh login to the Woo app
  2. Open the POS immediately after the tab shows up
  3. Confirm syncing state shows up
  4. Confirm the product list loads once the syncing is done
  5. Close POS/the app
  6. Reopen and select the POS tab
  7. Confirm syncing is no longer shown

Switch sites

  1. Switch to another POS eligible site
  2. Open the POS immediately after the tab shows up
  3. Confirm syncing state shows up
  4. Tap "Exit POS"
  5. Wait for sync to complete (observe logs)
  6. Open POS
  7. Confirm the syncing state is no longer shown, and POS opens

Testing information

Tested on iPad Air 26 simulator.

Screenshots

catalog.sync.full.mov

  • I have considered if this change warrants user-facing release notes and have added them to RELEASE-NOTES.txt if necessary.

@staskus staskus added this to the 23.6 milestone Oct 23, 2025
@staskus staskus requested a review from joshheald October 23, 2025 09:04
@staskus staskus added type: task An internally driven task. feature: POS labels Oct 23, 2025
@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Oct 23, 2025

App Icon📲 You can test the changes from this Pull Request in WooCommerce iOS Prototype by scanning the QR code below to install the corresponding build.

App NameWooCommerce iOS Prototype
Build Numberpr16272-6f0230e
Version23.5
Bundle IDcom.automattic.alpha.woocommerce
Commit6f0230e
Installation URL1kebak5i64dk0
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@joshheald joshheald self-assigned this Oct 23, 2025
Copy link
Contributor

@joshheald joshheald left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also made me think of creating a task for caching the eligibility check, and if the POS becomes ineligible, just showing a full-screen overlay. It's an unlikely scenario, and it's better to show POS immediately to give a fast execution impression.

Eligibility for POS, or for local catalog? For the latter, see my latest PR, it moves towards that and has an element of caching. We fall back to the network based approach if they're ineligible. #16276

I've had some initial issues when testing this:

  • Catalogs over 1000 items attempt to sync, and never leave the syncing state when it fails. For these sites, we shouldn't even attempt the sync, but this seems to be solved if you merge the PR I mentioned above.
  • Syncing catalog can complete, but not transition to the item list, it just spins forever. This happened when I tried to sync an eligible store after opening POS for a store with too many products for local catalog, so I think there may be some state leaked?

Copy link
Contributor

@joshheald joshheald left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works well, at least when merged with #16276, but I've left some questions/thoughts in line about another way we could approach it which might be easier

Spacer()
ProgressView()
.progressViewStyle(POSProgressViewStyle())
.matchedGeometryEffect(id: animation.progressTransitionId, in: animation.namespace, properties: .position)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using this existing LoadingView for local catalog syncs as well, rather than making a new view? That way we could set text fields to show, but keep using the same loading indicator and avoid visual glitches...

I think it would be a case of adding associated values to the containerState's loading enum case, perhaps?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that wouldn;t help with visual glitches.

We transition from PointOfSaleLoadingView within EntryPoint to PointOfSaleLoadingView within Dashboard, and when we add the text, the progress view moves up since, according to the designs, the whole container is centered, not the progress view. I tried centering only the progress view, but that doesn't look right.

But yes, we could keep one view that does more. We would still need to pass matched geometry from the EntryPoint to the Dashboard.

Comment on lines 632 to 640
Task { @MainActor [weak self] in
guard let self else { return }

catalogSyncState = await coordinator.lastFullSyncState(for: siteID)

for await state in coordinator.fullSyncStateStream {
catalogSyncState = state
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this task might never end, and therefore be a memory leak. It doesn't capture self, so no retain cycle, but we'll have the task hanging out for each time we leave and enter POS. Perhaps we should keep hold of it, and cancel it when we deinit?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it doesn't end since we want an endless stream. Good point on a memory leak. Need to be careful with this 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AsyncStream may not be what I want as a pattern or at least not the way I implemented it. Canceling a task terminates the stream automatically, which has to be recreated. Otherwise, it doesn't emit values anymore. I need a pattern that Observation or Combine implements for continuous value streaming to multiple listeners.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be fine to just keep hold of the task in a property, and cancel/finish when we deinit the model, wouldn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, canceling the Task terminates the stream. I think I found a better way just to use Observation. I will push the changes soon.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does that matter, on deinit? We want it to end then, surely.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We won't use AsyncStream anymore but that mattered because when AsyncStream is terminated, there was no mechanism to recreate it. It wouldn't emit any new results after opening POS for the second time.

Comment on lines 316 to 323
case .syncStarted(let id, _):
id
case .syncCompleted(let id):
id
case .syncFailed(let id, _):
id
case .syncNeverDone(let id):
id
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case .syncStarted(let id, _):
id
case .syncCompleted(let id):
id
case .syncFailed(let id, _):
id
case .syncNeverDone(let id):
id
case .syncStarted(let id, _), .syncCompleted(let id), .syncFailed(let id, _), .syncNeverDone(let id):
id

return lhsSiteID == rhsSiteID && lhsInitial == rhsInitial
case (.syncCompleted(let lhsSiteID), .syncCompleted(let rhsSiteID)):
return lhsSiteID == rhsSiteID
case (.syncFailed(let lhsSiteID, _), .syncFailed(let rhsSiteID, _)):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we OK with two failures that have different errors being considered equal?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. We shouldn't have to error transition, since we would have a loading state in between, but I guess it would be more correct to check error equality as well 🤔

Comment on lines 14 to 30
if let syncState = catalogSyncState {
switch syncState {
case .syncStarted(_, true):
return .catalogSyncing
case .syncNeverDone:
return .catalogSyncing
case .syncStarted(_, false):
// Non-initial sync, continue to other checks
break
case .syncCompleted:
// Continue to other checks
break
case .syncFailed:
// TODO: WOOMOB-1565
return .error(PointOfSaleErrorState.errorOnLoadingOrders())
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we'd avoid the need for this (and some of the async stream stuff) if the GRDBItemsController handled this and set the container state to .loading(.catalogSyncing)? I know it's a big change, and I could be missing some fundamental reason why it won't work or would be a bad way to go...

Copy link
Contributor Author

@staskus staskus Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. I mean, we would still need changes within the coordinator, since it's the one that does the syncing. Syncing can start at any time, so we need a mechanism that keeps and streams the syncing state.

In this case, I imagine the difference would be replacing aggregateModel+viewHelper with grdbItemsController, but other things would be mostly the same. Correct me if I'm wrong.

and some of the async stream stuff

Do you see how it can be avoided if we implemented the logic within the controller?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you see how it can be avoided if we implemented the logic within the controller?

No, thinking about it more we'd still need something like the stream, which should definitely still be in the coordinator, so that we can show the current sync if there's one already ongoing.

We'd be reading that stream in the GRDBItemsController, and setting the container state there too. I didn't really know that we used this helper, to be honest... it's probably fine in either place.

@staskus
Copy link
Contributor Author

staskus commented Oct 23, 2025

@joshheald Thanks for testing! Yes, I admit I found these tasks with combining sync states, GRDB observation, and SwiftUI more complicated and time-consuming than I anticipated. There are a lot of little details and even after thorough work and testing there are issues that I don't like to see...

I've had some initial issues when testing this:

Eligibility for POS, or for local catalog?

Eligibility for POS. I would imagine that with a loaded POS Catalog, we could launch straight into the POS without any loading screen. But that's a topic for future improvements.

Catalogs over 1000 items attempt to sync, and never leave the syncing state when it fails. For these sites, we shouldn't even attempt the sync, but this seems to be solved if you merge the PR I mentioned above.

Yes, I haven't addressed these limitations in this PR.

Syncing catalog can complete, but not transition to the item list, it just spins forever. This happened when I tried to sync an eligible store after opening POS for a store with too many products for local catalog, so I think there may be some state leaked?

When developing, I tested multiple times with various stores, but I haven't noticed this case. I will try to understand what could be happening here.

@staskus staskus force-pushed the woomob-1101-woo-poslocal-catalog-loading-screen-for-missing-catalog-when branch from 58e12ce to 45955b3 Compare October 23, 2025 16:15
…inator

Introduces a fullSyncStateStream and state cache to POSCatalogSyncCoordinator, allowing consumers to observe full sync state changes. Adds a POSCatalogSyncState enum to represent sync progress, completion, failure, and never-done states. Refactors sync logic to emit state updates and provides a method to query the last known sync state for a site.
Generic loading view appears briefly why POS model is loading. It's hard to get away from that. Synchronizing animations between both loading views allows to transition between both loading states without any noticeable transition issues.
@staskus staskus force-pushed the woomob-1101-woo-poslocal-catalog-loading-screen-for-missing-catalog-when branch from 45955b3 to 644c9ee Compare October 24, 2025 12:57
@staskus
Copy link
Contributor Author

staskus commented Oct 24, 2025

@joshheald thanks for the comments. I think I managed to address them with quite substantial changes:

  • Moved from using AsyncStream to @Observable for observing catalog sync state, which removed a category of issues and complications
  • Using coordinator.fullSyncState in PointOfSaleObservableItemsController, which automatically updates the container state if fullSyncState changes, not requiring any other observation logic
  • Merged POSCatalogLoadingView and PointOfSaleLoadingView , removed matchedGeometry and passing namespace
catalog.syncing.mov

Assigning to you, to merge after woomob-1098-woo-poslocal-catalog-add-incremental-sync-triggers-pull-to

Thank you again for all the help! 🙇

Base automatically changed from woomob-1098-woo-poslocal-catalog-add-incremental-sync-triggers-pull-to to trunk October 30, 2025 14:12
@dangermattic
Copy link
Collaborator

1 Warning
⚠️ This PR is assigned to the milestone 23.6. This milestone is due in less than 2 days.
Please make sure to get it merged by then or assign it to a milestone with a later deadline.

Generated by 🚫 Danger

@joshheald joshheald enabled auto-merge October 30, 2025 19:14
@joshheald joshheald merged commit 8e0c018 into trunk Oct 30, 2025
14 checks passed
@joshheald joshheald deleted the woomob-1101-woo-poslocal-catalog-loading-screen-for-missing-catalog-when branch October 30, 2025 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: POS type: task An internally driven task.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants